Skip to content

feat(parameters): declare a parameter set per entry of a map - #700

Open
azerupi wants to merge 1 commit into
azerupi/params/enum-parameter-setsfrom
azerupi/params/keyed-parameter-maps
Open

feat(parameters): declare a parameter set per entry of a map#700
azerupi wants to merge 1 commit into
azerupi/params/enum-parameter-setsfrom
azerupi/params/keyed-parameter-maps

Conversation

@azerupi

@azerupi azerupi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The code in this PR was assisted by Claude Code.

This PR lifts another limitation of the derive. A field of a parameter set can now be a map, declaring one parameter set per entry, with the entry names coming from whoever configures the node.

Problem

If we take the example of the previous PR that allows us to represent different sensors with different parameters through enum sets. We are still limited and can't easily represent different sensor configurations that are defined by the parameter file.

Declaring devices.front_lidar.rate needs code that already knows the string "front_lidar". Parameter overrides live in a private map on the parameter interface and are never inserted into the parameter map, so even use_undeclared_parameters` cannot see a parameter the node has not declared.

That left two options for the user, either hard-code the sensor names in the node, or bypass ROS 2 parameters for those values and give up on the niceties like descriptors, ranges, validation, ros2 param list and the parameter services.

Solution

A map field declares a parameter set for each of its entries. This composes very well with the enum sets from the previous PR, allowing the parameter file to say both which sensors are there and what kind each one is:

/// Configuration for one device, whatever kind it is.
#[derive(ParameterSet, Debug)]
#[parameters(rename_all = "snake_case")]
enum DeviceConfig {
    /// A 2D scanning lidar.
    Lidar {
        /// Scan rate in Hz.
        #[param(default = 30, range = 1..=100)]
        rate: i64,
        /// Frame this device reports in.
        #[param(default = "base_link")]
        frame_id: String,
    },
    /// A USB camera.
    Camera {
        /// Frame width in pixels.
        #[param(default = 1920)]
        width: i64,
        /// Frame height in pixels.
        #[param(default = 1080)]
        height: i64,
    },
}

#[derive(ParameterSet, Debug)]
struct DeviceHub {
    /// One entry per device, named in the parameter file.
    devices: BTreeMap<String, DeviceConfig>,
}
/device_hub:
  ros__parameters:
    devices:
      front_lidar:
        type: lidar
        rate: 40
      main_camera:
        type: camera
        width: 640

Neither the names nor the kinds appear anywhere in the node, and the values still arrive as plain Rust:

let config: DeviceHub = node.load_parameters()?;

for (name, device) in &config.devices {
    match device {
        DeviceConfig::Lidar { rate, frame_id } => ...,
        DeviceConfig::Camera { width, height } => ...,
    }
}

Both BTreeMap<String, S> and HashMap<String, S> work, for any S that derives ParameterSet. When the entries are all the same shape, that S is just a struct set. The keys are the names the entries are declared under, so they have to be String.

This works again through DeclareField. A map is just one more implementation of that trait, so nothing in the derive macro knows a map field from any other field and it emits the same line for it as for everything else.

Map keys

The names are recovered from the node's parameter overrides. override_names_under("devices") returns the distinct first path segment of every override under devices., so devices.front_lidar.type and devices.main_camera.type yield front_lidar and main_camera.

Each name found becomes the prefix of a full parameter set declaration:

$ ros2 param list /device_hub
  devices.front_lidar.frame_id
  devices.front_lidar.rate
  devices.front_lidar.type
  devices.main_camera.height
  devices.main_camera.type
  devices.main_camera.width

$ ros2 param describe /device_hub devices.front_lidar.type
  Type: string
  Description: Configuration for one device, whatever kind it is.
  Constraints: one of: lidar, camera
  Read only: true

devices.front_lidar.width does not exist, because that lidar is not a camera. The descriptions are there, the ranges are enforced, and the parameters can be watched for changes like any other. declare_parameters returns the handles per entry:

let params = node.declare_parameters::<DeviceHub>()?;
let front = &params.devices["front_lidar"];
assert_eq!(front.tag(), "lidar");

match &front.variant {
    DeviceConfigVariantParams::Lidar { rate, .. } => {
        rate.set(50)?;
        assert!(rate.set(500).is_err()); // the range still applies
    }
    _ => ...,
}

The entries are fixed at declaration time

The set of entries is decided when the map is declared, because that is when the parameters are declared. A name that turns up later over SetParameters names a parameter that does not exist, and is rejected like any other undeclared parameter. Adding a new entry needs a restart, the same way changing an enum set's tag does.

No overrides and no default result in an empty map.

A driver often manages a set of devices whose names only the integrator
knows: the sensors on a robot, the motors on an arm. That was not
expressible. Declaring sensors.front_lidar.rate requires code that already
knows the string "front_lidar", and there was no way to find it out:
overrides live in a private map on the parameter interface and are never
inserted into the parameter map, so use_undeclared_parameters cannot see a
parameter the node has not declared. The choice was to hard-code the
device names or to stop using ROS 2 parameters for those values, giving up
descriptors, ranges, validation, ros2 param list and the parameter
services.

A map field declares a parameter set for each of its entries, with the
entry names recovered from the parameters the node was configured with:

    #[derive(ParameterSet, Debug)]
    struct SensorHub {
        sensors: BTreeMap<String, SensorConfig>,
    }

Every leaf is an ordinary parameter, so sensors.front_lidar.rate has the
description and range SensorConfig gives it and can be watched for changes
like any other. Combined with an enum set, the entries do not have to be
the same kind of thing.

The entries are fixed when the map is declared, since that is when the
parameters are declared. A name that turns up later names a parameter that
does not exist. A default value contributes entries too, so a node can
have built-in ones that a parameter file adds to or overrides -- which is
what a default supplied for a whole set taking precedence over a field's
own default is for.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant